Skip to content

Conversation

@mattwellss
Copy link
Contributor

Original pull request includes discussion of limiting the response size of getPlayQueue, which isn't implemented at this point, but it's worth discussing

@paulijar
Copy link
Collaborator

Thanks. Looks good on the first glance. I'll do some testing later once I have time.

if (isset($current))
$playQueue['current'] = $current;
if (isset($position))
$playQueue['position'] = $position;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a good habit to always use curly braces on your if statements. For one famous example of what might happen if you don't, see goto fail.

Comment on lines 1081 to 1090
foreach ($playQueue['entry'] as &$entry) {
[$type, $id] = self::parseEntityId($entry);
if ($type === 'track') {
$entry = $this->trackToApi(
$this->trackBusinessLayer->find($id, $this->user())
);
} else if ($type === 'podcast_episode') {
$entry = $this->podcastEpisodeBusinessLayer->find($id, $this->user())->toSubsonicApi();
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder about this section. If an $entry somehow doesn't match either "track" or "podcast_episode", it'll be left in the response as an invalid element. Are there any other types of media that might end up saved in savePlayQueue? If this is a reasonable concern (and I dunno if it is), I can solve it by sanitizing the input on savePlayQueue or by generating a new array of just the valid items from $playQueue['entry'] in getPlayQueue

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question. The third kind of media playable on the Subsonic clients is a radio station. I'm not certain if any client puts those to the play queue, though. The API would allow also video playback, but this is not supported in the Music app (at least for now or in the near future).

But yeah, it would probably be safer to construct the list so that only the supported types of entities would be retained.

@paulijar
Copy link
Collaborator

paulijar commented Aug 17, 2025

Sorry for the delay, I have been busy with other things but now I managed to do some testing with this.

Functionally, I think this works pretty much as it should. There's one minor deviation from the Subsonic XML schema https://www.subsonic.org/pages/inc/api/schema/subsonic-rest-api-1.16.1.xsd as there the attribute username is defined as required but it's missing from the current implementation here. I would guess that no client actually uses this attribute for anything but better to follow the schema, still.

However, there's also one major issue now and that's the performance. The execution time of getPlayQueue now grows linearly with the number of tracks on the queue. For example, with 100 tracks in the queue, I saw execution time to be 25 seconds or more which is way too much. The reason for this is most certainly that there is call to $this->trackToApi for each track of the queue. This will cause several SQL queries to be executed per each track and all that adds up quickly.

The good news is that this is a kind of problem which has been already solved elsewhere in the Subsonic API. There exists the function SubsonicController::tracksToApi which does the conversion for multiple tracks at once, using clever tricks to minimize the number of SQL queries. So the function getPlayQueue should now be reorganized so that all the Track objects would be fetched first from TrackBusinessLayer and then all of them would be converted at once to the Subsonic API format. What makes this non-trivial is the fact that there may be podcast episodes interlaced among the songs. So likely, we would need to construct the result in multiple steps. It could be something like this:

$entries = \array_map(fn($entry) => self::parseEntityId($entry), $playQueue['entry']);
$trackEntries = \array_filter($entries, fn($entry) => $entry[0] === 'track');
$trackIds = \array_map(fn($entry) => $entry[1], $trackEntries);
$tracks = $this->trackBusinessLayer->findById($trackIds, $this->user());
$tracks = ArrayUtil::createIdLookupTable($tracks); // track IDs will become keys
$apiTracks = $this->tracksToApi($tracks); // this preserves the keys of the array

$entries = \array_map(function($entry) {
	[$type, $id] = $entry;
	if ($type === 'track') {
		return $apiTracks[$id] ?? null;
	} else if ($type === 'podcast_episode') {
		return $this->podcastEpisodeBusinessLayer->find($id, $this->user())->toSubsonicApi();
	} else {
		return null;
	}
}, $entries);
$entries = \array_filter($entries);

Caveat: I didn't try to run this, syntax errors are possible.

While at it, one thing to consider is what will happen in case the queue contains an ID which doesn't match any item in the library. This could happen for example if the item has been deleted since the queue was saved. At the moment, the action getPlayQueue will fail with the Subsonic error "entity not found". This will happen when BusinessLayer::find throws a BusinessLayerException and it's not caught within SubsonicController. It could be nicer if only the invalid entity was omitted but other parts of the queue would be returned. Of course, the case shouldn't be terribly common so the problem is quite minor.

Edit: I made another optimization on the example code above. Also the call to BusinessLayer::find makes one SQL query per each call. But it can be replaced with (a bit badly named) BusinessLayer::findById which allows finding many entities with a single query. The same optimization could still be made also on the podcast episodes.

Edit2: Using findById actually solves also the point about IDs which do not match any entities: This function will not throw but simply omit any items which were not found.

@mattwellss
Copy link
Contributor Author

Thanks for the review! I appreciate the testing/feedback and I'll work on those improvements. Unsure if I'll get more commits pushed until next weekend, though

@mattwellss
Copy link
Contributor Author

Edit: I made another optimization on the example code above. Also the call to BusinessLayer::find makes one SQL query per each call. But it can be replaced with (a bit badly named) Business::findById which allows finding many entities with a single query. The same optimization could still be made also on the podcast episodes.

Funny! I was just looking at the suggestion and wondering if bulk track find functionality existed :)

$playQueue = json_decode($this->configManager->getUserValue($this->user(), $this->appName, 'play_queue', 'false'), true);

if (!$playQueue) {
return $this->subsonicResponse([]);
Copy link
Contributor Author

@mattwellss mattwellss Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm returning an empty response rather than an empty playqueue when we haven't saved one. Seems better than generating metadata for a canned response, and it follows the API better: https://subsonic.org/pages/api.jsp#getPlayQueue

Returns a <subsonic-response> element with a nested <playQueue> element on success, or an empty <subsonic-response> if no play queue has been saved.

$pqEntries[] = $apiTracks[$id];
} else if ($type === 'podcast_episode') {
try {
$pqEntries[] = $this->podcastEpisodeBusinessLayer->find($id, $this->user())->toSubsonicApi();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to looking up tracks individually, many podcast episodes in a play queue could presumably cause a slow response. Should we include bulk episode find a la bulk track find as part of this work?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that would be good

'current' => $current,
/** @see Util::formatZuluDateTime (if only we could pass a datetime!) */
'changed' => $changedDateTime->format('Y-m-d\TH:i:s.v\Z'),
'username' => $u
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use $this->user() here instead of the URL argument $u. The reason is that the Music app supports the OpenSubsonic extension API Key Authentication which means that the client may pass the argument apiKey instead of u and p. The function will return the correct username in either case.

Another small difference is that the URL argument $u is case insensitive but $this->user() always contains the "proper" casing i.e. the same format in which the username was given when the account was created.

@paulijar
Copy link
Collaborator

Performance-wise, the latest version works great with local songs: While the 100-song queue previously took 25+ seconds, now I can get a 500-song queue in less than a second. 👍

Comment on lines 1084 to 1113
$typeLookupMap = [
'track' => [
'lookupFn' => [$this->trackBusinessLayer, 'findById'],
'toApiFn' => [$this, 'trackstoApi']
],
'podcast_episode' => [
'lookupFn' => [$this->podcastEpisodeBusinessLayer, 'findById'],
'toApiFn' => [$this, 'podcastEpisodestoApi']
]
];

/** @var array{'track': Track[], 'podcast_episode': PodcastEpisode[]} $apiEntries */
$apiEntries = array_merge([], ...array_map(
fn ($type, $methods) => [$type => $methods['toApiFn'](
ArrayUtil::createIdLookupTable($methods['lookupFn'](
\array_map(
fn ($entry) => $entry[1],
\array_filter($parsedEntries, fn ($parsedEntry) => $parsedEntry[0] === $type)
),
$this->user()
))
)],
array_keys($typeLookupMap),
array_values($typeLookupMap)
));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PHP doesn't necessarily lend itself super great to this sort of programming, so I totally get it if you want to see intermediate vars introduced here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the functional programming style but it is true that this is a bit difficult to read or at least requires a lot of focusing to understand it correctly. As a rule of thumb, having more than two level of nesting in control structures is a bit of a code smell, and the same could probably be applied on these array_* functions which are essentially just higher-level control structures. Sometimes the deep nesting might be the best option but at least it's a sing that considering also other design options could be fruitful.

So if you can make it any easier to read with intermediate vars, then that's good. But if not, then that's not a blocker for merging. I'll give this another test round on Sunday or next week. If no problems are found, I'll merge this, unless you want to still change something.

I'm planning to release Music v2.3.0 rather soon, and I'd like to get this merged before that. The deadline for the release is the end of September, before NC32, but preferably I'd like to do it during the next two weeks.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry to chime in, I'm just mostly agreeing and feel free to ignore me, I understand the release is more important, and I don't want to start a long discussion. I don't think PHP gets functional programming right, which you might already know. The best use of FP makes code also declarative and therefore easy to read. However, in PHP, array_map puts the function parameter first, which which poses multiple problems like being inconsistent with array_filter, for example. This makes me want to use a better functional library instead, like Pipeline, but it's just an example, it may not be appropriate to use in a Nextcloud App, and the function chaining may limit you. But at least Pipeline starts the logic with the first thing to do, so you don't read function calls backwards. Also, array_reduce, just like in other languages, is more of a primitive higher-order function, and therefore not so readable; usually, if you can use something better, you should. (Of course this is situational, and may not always be applicable.) I think if the code doesn't mix high- and low-level logic and stays declarative, then it doesn't necessarily harm code readability when you use multiple levels of nesting. It's just that other languages do it better, and more concisely.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback. Really looking forward to a new release and using this with my real library. I made an effort to create a few steps rather than requiring the reader's brain to hold all the transformations. If it's not better, at least it's a little different :D

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's good enough for now. If it starts bothering me, then maybe I'll reconsider the design later.

Anyway, I now tested the latest version and it seemed to run without any issues. Even Dsub worked now without any hick-ups with 500-item queues; maybe the problems I saw in it handling the long queues with the previous play-queue-PR was due to some of the API-noncompliances of that implementation 🤷.

jplitza and others added 18 commits August 24, 2025 22:41
The extra attributes "position" and "current" (track) are saved as JSON
comment of the playlist.
 - only tracks and podcast episodes allowed
 - add changedBy, position, and current, then array_filter nulls out
 - inline formatted changed datetime string
refactor playqueue['entry'] generation
- $type is now a sibling of functions in handler array
- generate intermediate var for type's entity ids
- generate intermediate var for type's entity instances
- switch from array_reduce to array_filter'd array_map
…nctions

This mechanism enabling a bit more concise expression has been introduced
after the first PR for Subsonic play queue support was started.
@paulijar paulijar force-pushed the feature/1103_queue-sync branch from c14efb5 to cfa7048 Compare August 24, 2025 20:05
@scrutinizer-notifier
Copy link

A new inspection was created.

@paulijar paulijar merged commit 83d572c into owncloud:master Aug 24, 2025
@mattwellss mattwellss deleted the feature/1103_queue-sync branch August 24, 2025 20:12
@paulijar
Copy link
Collaborator

This is now released in Music v2.3.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support synced play queue

5 participants